Create New Feature Screen

Before you dive in, it may be helpful to get familiar with the companion document Screen Structure and Wiring, which explains how the individual classes fit together. With that context in mind, this guide walks you through creating a new feature screen in the project. You can choose one of three paths:

  1. Using file templates — generate the screen files, then add the route and module dependencies
  2. Ask an AI agent — follow the repository's screen-creation skill
  3. Manual setup — when you need full control or when templates aren't available

Option 1: Using File Templates

  1. Ensure the UI Feature template is installed
    • File → Manage IDE Settings → Import Settings...
    • Import file_templates.zip from the baselines-kmp root dir
  2. Right-click a destination package under <namespace>.ui.<feature> (e.g., com.example.myapp.ui.profile) → New → UI Feature
  3. Enter the feature name (e.g., Profile, Settings)
  4. Keep the generated UiModule registration: the current archive uses NavEntryFactory / entry and Metro ViewModel bindings, matching step 7 below. Imports, including the preview's AppTheme, are derived automatically from the package prefix before .ui..
  5. Add the route and register the feature module as shown in steps 6 and 8 below.

Reimport the current archive if your IDE still generates hard-coded imports or NavGraphEntry / composable wiring.

Option 2: Setup via AI Agent

  1. Open chat with your AI assistant and point it to AGENTS.md and .agents/skills/create-screen/SKILL.md.
  2. Ask to create new feature screen
  3. Provide all the necessary info requested by the AI agent
  4. Done 🎉 — your feature is wired into the app

Option 3: Manual Setup

  1. Create *UiEvent
kotlin
1sealed interface ProfileUiEvent : UiEvent {
2 data object PerformLogout : ProfileUiEvent {
3 override val dispatchPolicy = UiEventDispatchPolicy.ThrottleFirst()
4 }
5}

Use ThrottleFirst for click-like one-shot events. Use DebounceLatest only for delayed latest-value effects such as search or filtering. If a text field is controlled by ViewModel state, keep the visible input state immediate and debounce a separate effect event instead of the input-state event itself.

  1. Create *UiState
kotlin
1@Immutable
2data class ProfileUiState(
3 override val eventSink: (ProfileUiEvent) -> Unit,
4) : UiState<ProfileUiEvent>
  1. Create *ViewModel

Use the standard Metro map contribution below when all dependencies come from the app graph. It does not require a separate @Inject annotation. Use @AssistedInject and an assisted factory only for runtime arguments, as shown by PlaygroundViewModel.

kotlin
1@ViewModelKey
2@ContributesIntoMap(AppScope::class, binding<ViewModel>())
3class ProfileViewModel : ViewModel(), Mvvm<ProfileUiEvent, ProfileUiState> {
4
5 private val eventSink = createEventSink(::handleEvent)
6
7 @Composable
8 override fun state() = ProfileUiState(
9 eventSink = eventSink,
10 )
11
12 private fun handleEvent(event: ProfileUiEvent) {
13 when (event) {
14 ProfileUiEvent.PerformLogout -> handleLogout()
15 }
16 }
17
18 private fun handleLogout() {
19 /* handle action */
20 }
21}
  1. Create *Screen
kotlin
1@Composable
2fun ProfileScreen(onLogoutClicked: () -> Unit) {
3 /* UI */
4}
  1. Create *Route
kotlin
1@Composable
2fun ProfileRoute(viewModel: ProfileViewModel) {
3 val state = viewModel.state()
4 val eventSink = state.eventSink
5 ProfileScreen(
6 onLogoutClicked = { eventSink(ProfileUiEvent.PerformLogout) }
7 )
8}
  1. Add the destination to the existing AppNavRoutes object in ui/navigation
kotlin
1@Serializable
2data object Profile : NavRoute

Use kotlinx.serialization.Serializable and the app's io.baselines.sample.ui.navigation.NavRoute. Keep the existing routes and AppNavRoutes.Default. Change GetStartRoute only if this screen should be the app's start destination.

  1. Add DI *Module
kotlin
1import dev.zacsweers.metro.BindingContainer
2import dev.zacsweers.metro.ContributesTo
3import dev.zacsweers.metro.IntoSet
4import dev.zacsweers.metro.Provides
5import dev.zacsweers.metrox.viewmodel.metroViewModel
6import io.baselines.sample.ui.navigation.AppNavRoutes
7import io.baselines.sample.toolkit.di.UiScope
8import io.baselines.sample.toolkit.navigation.NavEntryFactory
9
10@BindingContainer
11@ContributesTo(UiScope::class)
12object ProfileUiModule {
13
14 @Provides
15 @IntoSet
16 fun provideProfileNavEntryFactory(): NavEntryFactory = {
17 entry<AppNavRoutes.Profile> {
18 ProfileRoute(metroViewModel())
19 }
20 }
21}
  1. Register the feature module

If this is a new module, add include(":ui:profile") to settings.gradle.kts and api(projects.ui.profile) to commonMain.dependencies in app/multiplatform/build.gradle.kts. The feature module should apply the Baselines Compose and DI plugins and depend on projects.ui.navigation, projects.ui.viewModel, and projects.ui.designSystem as needed. See Create New Module for build setup.

  1. Navigate from a ViewModel

Inject io.baselines.sample.toolkit.navigation.Navigator into the ViewModel handling the action:

kotlin
1navigator.navigate(AppNavRoutes.Profile)
2// From a screen with a back action:
3navigator.navigateBack()

These commands are synchronous and do not require a coroutine. The Navigation Guide covers stack replacement and parameterized routes.